/*!
 * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
 * Licensed under the MIT License.
 */

import { writeFile } from "node:fs/promises";
import path from "node:path";
import { Flags } from "@oclif/core";
import { StringBuilder } from "@rushstack/node-core-library";
import { format as prettier } from "prettier";
import { remark } from "remark";
import remarkGfm from "remark-gfm";
import remarkGithub, { defaultBuildUrl } from "remark-github";
import admonitions from "remark-github-beta-blockquote-admonitions";
import remarkToc from "remark-toc";

import type { ReleaseNotesSection } from "../../config.js";
import { releaseGroupFlag } from "../../flags.js";
import {
	BaseCommand,
	DEFAULT_CHANGESET_PATH,
	UNKNOWN_SECTION,
	difference,
	fluidCustomChangeSetMetadataDefaults,
	groupBySection,
	loadChangesets,
} from "../../library/index.js";
// eslint-disable-next-line import-x/no-internal-modules
import { addHeadingLinks, stripSoftBreaks } from "../../library/markdown.js";
// eslint-disable-next-line import-x/no-internal-modules
import { RELEASE_NOTES_TOC_LINK_TEXT } from "../../library/releaseNotes.js";

/**
 * Generates release notes from individual changeset files.
 */
export default class GenerateReleaseNotesCommand extends BaseCommand<
	typeof GenerateReleaseNotesCommand
> {
	static readonly summary = `Generates release notes from individual changeset files.`;

	// Enables the global JSON flag in oclif.
	static readonly enableJsonFlag = true;

	static readonly flags = {
		releaseGroup: releaseGroupFlag({
			required: true,
		}),
		releaseType: Flags.custom<"major" | "minor">({
			char: "t",
			description: "The type of release for which the release notes are being generated.",
			options: ["major", "minor"],
			required: true,
			parse: async (input) => {
				if (input === "major" || input === "minor") {
					return input;
				}

				throw new Error(`Invalid release type: ${input}`);
			},
		})(),
		outFile: Flags.file({
			description: `Output the results to this file.`,
			required: true,
			default: "RELEASE_NOTES.md",
			deprecateAliases: true,
			aliases: [
				// Can be removed in 0.46+
				"out",
			],
		}),
		includeUnknown: Flags.boolean({
			default: false,
			description:
				"Pass this flag to include changesets in unknown sections in the generated release notes. By default, these are excluded.",
		}),
		headingLinks: Flags.boolean({
			default: false,
			description:
				"Pass this flag to output HTML anchor anchor tags inline for every heading. This is useful when the Markdown output will be used in places like GitHub Releases, where headings don't automatically get links.",
		}),
		excludeH1: Flags.boolean({
			default: false,
			description:
				"Pass this flag to omit the top H1 heading. This is useful when the Markdown output will be used as part of another document.",
		}),
		...BaseCommand.flags,
	} as const;

	static readonly examples = [
		{
			description: `Generate release notes for a minor release of the client release group.`,
			command: "<%= config.bin %> <%= command.id %> -g client -t minor",
		},
		{
			description: `You can output a different file using the --out flag.`,
			command:
				"<%= config.bin %> <%= command.id %> -g client -t minor --out RELEASE_NOTES/2.1.0.md",
		},
	];

	public async run(): Promise<string> {
		const context = await this.getContext();
		const { flags, logger } = this;

		const releaseGroup = context.repo.releaseGroups.get(flags.releaseGroup);
		if (releaseGroup === undefined) {
			this.error(`Unknown release group: ${flags.releaseGroup}`, { exit: 2 });
		}

		const { releaseNotes: releaseNotesConfig } = context.flubConfig;
		if (releaseNotesConfig === undefined) {
			this.error(
				`No release notes config found. Make sure the 'releaseNotes' section of the build config exists.`,
				{ exit: 2 },
			);
		}

		const changesetDir = path.join(releaseGroup.directory, DEFAULT_CHANGESET_PATH);
		const changesets = await loadChangesets(changesetDir, logger);

		const { version } = releaseGroup;
		const header = `<!-- THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -->`;
		const footer = `### 🛠️ Start Building Today!\n\nPlease continue to engage with us on GitHub
[Discussion](https://github.com/microsoft/FluidFramework/discussions) and
[Issue](https://github.com/microsoft/FluidFramework/issues) pages as you adopt Fluid Framework!
`;
		const intro = flags.excludeH1
			? "## Contents"
			: `# Fluid Framework v${version}\n\n## Contents`;

		this.info(`Loaded ${changesets.length} changes.`);

		const bySection = groupBySection(changesets);
		const sectionsToBuild: Map<string, ReleaseNotesSection> = new Map(
			Object.entries(releaseNotesConfig.sections),
		);

		// Create a new scope since this code section is standalone
		{
			const unknownSection = bySection.get(UNKNOWN_SECTION);
			for (const changeset of unknownSection ?? []) {
				if (changeset.additionalMetadata?.includeInReleaseNotes !== false) {
					this.warning(
						`Changeset doesn't map to known sections. Check its metadata: ${changeset.sourceFile}`,
					);
				}
			}

			const sectionsInChangesets = new Set<string>(bySection.keys());
			const configuredSections = new Set<string>(Object.keys(releaseNotesConfig.sections));
			const unknownSections = difference(sectionsInChangesets, configuredSections).add(
				UNKNOWN_SECTION,
			);

			if (flags.includeUnknown) {
				for (const sectionName of unknownSections) {
					sectionsToBuild.set(sectionName, { heading: `Unknown section: ${sectionName}` });
				}
			} else {
				for (const section of unknownSections) {
					if (section !== UNKNOWN_SECTION) {
						this.error(
							`Could not find a configuration for a section named "${section}". All sections must be configured.`,
							{ exit: 2 },
						);
					}
				}
			}
		}

		const body = new StringBuilder();
		for (const [name, { heading: sectionHead }] of sectionsToBuild.entries()) {
			this.verbose(`Building "${name}" section with header: ${sectionHead}`);
			const changes = bySection.get(name)?.filter(
				(change) =>
					// filter out changes that shouldn't be in the release notes
					(change.additionalMetadata?.includeInReleaseNotes ??
						fluidCustomChangeSetMetadataDefaults.includeInReleaseNotes) === true,
			);
			if (changes === undefined || changes.length === 0) {
				this.info(`No changes in section "${name}", so it will be omitted.`);
				continue;
			}

			body.append(`## ${sectionHead}\n\n`);
			for (const change of changes) {
				if (
					// A changeset may apply to no packages, in which case the changeTypes.length will be 0. Such changesets are
					// useful to add information to the release notes that don't apply to individual packages. Also useful when
					// the relevant packages have all been deleted.
					change.changeTypes.length === 0 ||
					// If the change's type matches the release type flag, the change should be included.
					change.changeTypes.includes(flags.releaseType)
				) {
					const pr = change.commit?.githubPullRequest;
					const changeTitle = pr === undefined ? change.summary : `${change.summary} (#${pr})`;
					body.append(`### ${changeTitle}\n\n${change.body}\n\n`);

					body.append(`#### Change details\n\n`);
					if (change.commit?.sha !== undefined) {
						body.append(`Commit: ${change.commit.sha}\n\n`);
					}
					const affectedPackages = Object.keys(change.metadata)
						.map((pkg) => `- ${pkg}\n`)
						.join("");
					body.append(`Affected packages:\n\n${affectedPackages}\n\n`);
					body.append(
						`[${RELEASE_NOTES_TOC_LINK_TEXT}](#${flags.headingLinks ? "user-content-" : ""}contents)\n\n`,
					);
				} else {
					this.info(
						`Excluding changeset: ${path.basename(change.sourceFile)} because it has no ${
							flags.releaseType
						} changes.`,
					);
				}
			}
		}

		const baseProcessor = remark()
			.use(remarkGfm)
			.use(stripSoftBreaks)
			.use(admonitions, {
				titleTextMap: (title) => ({
					// By default the `[!` prefix and `]` suffix are removed; we don't want that, so we override the default and
					// return the title as-is.
					displayTitle: title,
					checkedTitle: title,
				}),
			})
			.use(remarkToc, {
				maxDepth: 3,
				skip: ".*Start Building Today.*",
				// Add the user-content- prefix to the links when we generate our own headingLinks, because GitHub will
				// prepend that to all our custom anchor IDs.
				prefix: flags.headingLinks ? "user-content-" : undefined,
			})
			.use(remarkGithub, {
				buildUrl(values) {
					// Disable linking mentions
					return values.type === "mention" ? false : defaultBuildUrl(values);
				},
			});

		const processor = flags.headingLinks ? baseProcessor.use(addHeadingLinks) : baseProcessor;

		const contents = String(
			await processor.process(`${header}\n\n${intro}\n\n${body.toString()}\n\n${footer}`),
		);

		const outputPath = path.join(context.repo.resolvedRoot, flags.outFile);
		this.info(`Writing output file: ${outputPath}`);
		await writeFile(
			outputPath,
			await prettier(contents, { proseWrap: "never", parser: "markdown" }),
		);

		return contents;
	}
}
